refactor: split /api/request into focused modules, add tests, add analytics#57
Conversation
|
@askov is attempting to deploy a commit to the Solana Foundation Team on Vercel. A member of the Team first needs to authorize it. |
| const canBypass = | ||
| isAuthorizedToBypass(ctx.authToken) || isAllowListedIp(ctx.ip); | ||
|
|
||
| if (!canBypass) { |
There was a problem hiding this comment.
Do I get it right that we should allow airdrops for some tokens?
Previously, GitHub auth was required anyway, even for the token, but the current PR changes this and makes the criteria for airdrop, in case of a token, wider?
There was a problem hiding this comment.
That is likely to be fixed
There was a problem hiding this comment.
I tweaked the conditions to match main as closely as possible. This bypass-by-IP-and-token feature isn't used - it's secured at the infra level with Cloudflare. I'd delete it to simplify the code, but that's out of scope. I tried to stay close to the current feature set.
…lytics * Refactor /api/request into focused modules, add test, add analytics * Reject non-object bodies as 400, fix rpc fallback test - buildContext now type-guards req.json() output so null/array bodies return INVALID_BODY (400) instead of crashing into a generic 500. - Rewrite getRpcUrl tests to vi.resetModules() + dynamic import so the stubbed env is actually picked up (RPC_URLS is captured at import time); add cases for both defaults and overrides. - Drop dead `?? undefined` on getClientIp's || chain. * Omit github_id for bypass callers, align FaucetTransaction with backend The backend's POST /api/transactions schema is .strict() with githubIdSchema.optional() (regex /^\d+$/). Passing "" for IP/auth-token bypass callers would fail the regex and 400 — silently, since the recordTransaction call is wrapped in a logging try/catch — leaving bypass-airdrops out of the audit trail. - Pass ctx.githubUserId directly so JSON.stringify omits the key when undefined; backend treats it as missing and accepts. - Type transactionsAPI.create's github_id as string | undefined. - Rename local getLastTransactions param github_username → github_id. - Rename FaucetTransaction.github_username → github_id to match the faucet.transactions column the backend actually returns. - Add a regression test asserting recordTransaction receives undefined for the GitHub ID on bypass paths. * Remove dead backend client methods solanaBalancesAPI.getAllForAccount and githubValidationAPI both target backend routes (GET /solana-balances/account/:account, GET /gh-validation/:userId) that don't exist — and have no callers in the app. Drop them and the githubValidationAPI export.
The refactor in 562925b collapsed three independent gates (GitHub auth, captcha, rate limits) behind a single `canBypass` flag, so an allow-listed IP skipped all three. cf-connecting-ip is set from a request header that any client can forge if the Vercel origin is reachable without going through Cloudflare, turning a spoofable trust signal into a full authorization bypass. Restore main's original semantics: IP_ALLOW_LIST only short-circuits rate limiting; GitHub auth and captcha still run. AUTH_TOKENS_ALLOW_LIST remains a full bypass since it's gated by a constant-time secret compare, which is a real authentication signal. Tests updated to assert the corrected matrix, including a regression test that a spoofed cf-connecting-ip with no GitHub session is rejected with github_auth_required.
2e125ff to
d68a6f4
Compare
- Split RPC_URL into RPC_URL_DEVNET / RPC_URL_TESTNET to match lib/rpc.ts after the per-network refactor. - Document GA4_MEASUREMENT_ID and GA4_API_SECRET for the new server-side analytics in lib/analytics.ts; both are optional (trackEvent is a no-op when either is unset). - Add the GA4 vars to types.d.ts so process.env access type-checks.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Warning
This PR renames one production env var and adds two new optional ones. Update Vercel env before promoting to production.
Rename (action required)
RPC_URLRPC_URL_DEVNET,RPC_URL_TESTNETlib/rpc.tsnow resolves the RPC URL per network. The oldRPC_URLis no longer read.If you forget to set
RPC_URL_DEVNET, devnet silently falls back to the publichttps://api.devnet.solana.com— the airdrop endpoint still works, but you lose the private RPC throughput / API key. No errors are surfaced.Add (optional — server-side GA4 analytics)
GA4_MEASUREMENT_IDG-XXXXXXXXXX. When unset,trackEvent()is a no-op.GA4_API_SECRETPre-deploy checklist
RPC_URL_DEVNET(andRPC_URL_TESTNETif you want a private testnet RPC) in Vercel production envGA4_MEASUREMENT_ID+GA4_API_SECRETto enable analyticsRPC_URLto avoid confusionSummary
Refactors the
/api/requestroute handler from a 346-line monolith into a set of focused, individually testable modules, adds a vitest-based test suite (now wired into CI), introduces lightweight server-side analytics for airdrop outcomes, and tightens a couple of security and operational rough edges surfaced while pulling things apart.A security-equivalence audit (old monolith vs new modular implementation) is in the Security review section below — no controls weakened.
Module split —
app/api/request/route.tsis now a thin entrypoint; the logic lives in:handler.ts— orchestrates a request end-to-endvalidation.ts— body parsing +400 INVALID_BODYfor non-object bodies (null, arrays)ip.ts— client IP extraction, sanitization, and IP-allow-list parsing (fault-tolerant: invalid JSON now falls back to[]instead of breaking all traffic)auth-bypass.ts— auth-token bypass with timing-safe comparison (timingSafeEqual) replacing the old==rate-limiting.ts— rate-limit checksverify-captcha.ts— Cloudflare Turnstile verificationexecute-airdrop.ts— Solana airdrop callairdrop-error.ts— error → HTTP status + user-facing message mapping (NO_KEYPAIRnow correctly 500 instead of 400; everything else preserved)tracking.ts— analytics + backend transaction recordingtypes.ts— sharedRequestContext/AirdropResponsetypes;faucetKeypairandauthTokenare non-enumerable with atoJSONreturning<redacted>, so an accidentalconsole.log(ctx)cannot leak themTests —
vitestvitest.config.tswith__tests__-scoped include pattern:app/**/__tests__/**/*.test.ts,lib/**/__tests__/**/*.test.ts.github/workflows/ci.yml) sonpm testruns on every PRAnalytics —
lib/analytics.tsServer-side GA4 Measurement Protocol wrapper that records airdrop outcome events (
airdrop_success,airdrop_failedwith structuredreason) from API routes. Fire-and-forget — never throws, never blocks the airdrop response. No PII: full wallet addresses are shortened toXXXX...YYYYbefore being sent; sanitized IP is used only as the GA4client_idfor visitor grouping; no auth tokens or keypair material are ever emitted. Disabled cleanly when env vars are unset.Library cleanup
lib/utils.ts+lib/constants.tsinto focusedlib/airdrop/(AIRDROP_TIERS,VALID_AMOUNTS,resolveTier) andlib/rpc.ts(getConnection, per-network env-aware) modulesgetClientIp(drop dead?? undefined)getRpcUrltests withvi.resetModules()+ dynamic import so stubbed env is picked up (RPC_URLSis captured at import time)Backend client fixes (
lib/backend.ts)POST /api/transactionsis.strict()withgithubIdSchema.optional()(regex/^\d+$/), and passing""failed the regex with a 400 swallowed by the surrounding logging try/catch. Passctx.githubUserIddirectly soJSON.stringifyomits the key whenundefinedand the backend accepts it as missing.FaucetTransaction.github_username→github_idto match the column the backend actually returns; rename the localgetLastTransactionsJS param accordingly. Wire format unchanged — the HTTP query key was alreadygithub_id.transactionsAPI.create'sgithub_idasstring | undefined.solanaBalancesAPI.getAllForAccountandgithubValidationAPItarget backend routes that don't exist and have no callers.Other fixes folded into this PR
fix: restrict IP_ALLOW_LIST to rate-limit bypass only— preserves the old monolith's semantics: an IP-allow-listed caller still gets captcha-checked and still needs GitHub auth; the allow-list only skips rate-limit lookup.fix: redact-secrets helper— theRequestContextmentioned above (non-enumerable secrets +toJSON: <redacted>), with a regression test that assertsJSON.stringify(ctx)does not contain the keypair or auth token.Behavior changes worth flagging
Verified against the pre-refactor monolith on
main:GITHUB_LOGIN_REQUIRED = trueunconditionally, so a bypass-token caller without a GitHub login was rejected. Aligns with the whole point of bypass tokens (machine/CI use) and with the backendgithub_idbecoming optional. IP-allow-listed callers still need GitHub in both versions.0 < amount ≤ maxAmountPerRequestaccepted0.1,3, and (latent bug) negative numbers. New: only values inVALID_AMOUNTS = [0.5, 1, 2.5, 5]are accepted. UI is unaffected; any external/scripted caller posting non-whitelisted amounts will now get a 400.INVALID_BODY400 for malformed bodies (null, arrays, non-JSON), previously a generic 500.Everything else — Cloudflare IP determination with dev
::1fallback, captcha skip conditions (fail-closed on Cloudflare errors), rate-limit math, validationAPI / transactionsAPI argument order and wire keys, faucet signing keypair, lamports math, priority fee, record-fail-open, error message strings — is preserved bit-for-bit.Test plan
npm test— full vitest suite passes (100+ tests, all green)npx tsc --noEmit— type-clean/api/requestwith a valid wallet + Turnstile token → airdrop succeeds, transaction recorded/api/requestfrom an IP-allow-listed caller → captcha still required, GitHub still required, but rate limit skipped/api/requestwith a valid bypass token → captcha/GitHub/rate-limit all skipped, transaction recorded withgithub_idomitted/api/requestwith a malformed body (null, array, non-JSON) →400 INVALID_BODY/api/requestwithamount: 0.1→400(was previously accepted)429, analyticsairdrop_failedwithreason: rate_limited400, analyticsairdrop_failedwithreason: captcha_failedRPC_URL_DEVNETis set and devnet airdrops route through the private RPC